Skip to content

Implement plugin system with analytics, social icons, and scholar plugins#520

Merged
compscidr merged 12 commits intomainfrom
feature/plugin-system
Mar 17, 2026
Merged

Implement plugin system with analytics, social icons, and scholar plugins#520
compscidr merged 12 commits intomainfrom
feature/plugin-system

Conversation

@compscidr
Copy link
Copy Markdown
Collaborator

@compscidr compscidr commented Mar 17, 2026

Summary

Adds a hybrid plugin system supporting compiled-in Go plugins and dynamically loaded plugins via the Yaegi interpreter. Includes three production plugins that replace previously hardcoded features.

Plugin Architecture

Interface — plugins implement only what they need via BasePlugin embedding:

  • Settings() — plugin-specific config shown in admin UI, stored in dedicated plugin_settings table
  • ScheduledJobs() — periodic background tasks
  • TemplateHead() / TemplateFooter() — inject HTML into pages
  • TemplateData() — inject data accessible in templates as {{ .plugins.name.key }}
  • Pages() — register dynamic page types
  • RenderPage() — handle rendering plugin-owned pages

Two loading modes:

  • Compiled-in: Import and register in main()
  • Dynamic: Drop .go files into plugins/dynamic/, loaded at startup via Yaegi — no recompilation needed

Admin Settings UI:

  • Plugin settings separated from core settings in their own cards
  • Each plugin has a collapsible card with enable/disable toggle
  • Cards start collapsed, clickable header to expand
  • Per-plugin save buttons

Included Plugins

Google Analytics (plugins/analytics/)

  • Configurable measurement ID and enable toggle
  • Injects GA script into <head> when enabled

Social Icons (plugins/socialicons/)

  • 10 social platform URLs (GitHub, LinkedIn, X, etc.)
  • Renders icon links in footer via TemplateFooter
  • Migration moves existing social URL settings from core settings table to plugin_settings
  • Enabled by default for existing installs

Google Scholar (plugins/scholar/)

  • Registers a "research" dynamic page type
  • Auto-creates the page record in DB on init (user customizes title, slug, hero via admin)
  • Migrates ScholarID from legacy page field to plugin settings
  • Daily background cache refresh via scheduled job
  • Configurable: scholar ID, article limit, cache file paths
  • When disabled: page disappears from nav, shows 404 to visitors

Integration Changes

  • Blog.Render() helper wraps c.HTML() with plugin data injection (all 32 calls migrated)
  • plugin_head_html / plugin_footer_html injection points in all theme headers/footers
  • PageFilter on Blog filters nav pages — hides pages owned by disabled plugins
  • DynamicPage default case delegates to plugin registry, shows 404 for disabled plugin pages
  • Admin pages list shows "plugin disabled" badge and yellow highlight
  • Admin page editor shows warning banner with link to enable the plugin
  • Scholar-specific fields removed from page editor (now in plugin settings)
  • Scholar removed from Blog struct — no longer a core dependency
  • Migrations clean up plugin-namespaced keys from main settings table

File Structure

plugin/
  plugin.go         — Plugin interface, BasePlugin, types
  registry.go       — Registry, middleware, template injection, page routing
  registry_test.go  — Tests for registration, seeding, data injection
  setting.go        — PluginSetting model (separate table)
  loader.go         — Yaegi dynamic plugin loader
  symbols.go        — Type exports for Yaegi interpreter
plugins/
  analytics/        — Google Analytics plugin
  socialicons/      — Social media icon links plugin
  scholar/          — Google Scholar integration plugin

Closes #480

Test plan

  • All existing tests pass
  • Plugin registry tests: registration, setting seeding, template data injection, GetAllSettings
  • Scholar sort test migrated to plugin package
  • Enable/disable analytics plugin — GA script appears/disappears from page source
  • Enable/disable social icons — footer icons appear/disappear
  • Enable/disable scholar — research page appears/disappears from nav, shows 404 when disabled
  • Admin pages list shows "plugin disabled" badge for research when scholar disabled
  • Admin page editor shows warning banner for disabled plugin pages
  • Plugin settings save correctly via per-plugin save buttons

🤖 Generated with Claude Code

Adds a plugin architecture that allows extending goblog without
forking. Plugins can inject HTML into templates, provide data to
templates, register scheduled background jobs, and define their
own settings in the admin UI.

Plugin interface (plugin/plugin.go):
- Name, DisplayName, Version for identity
- Settings() for plugin-specific configuration
- ScheduledJobs() for periodic background tasks
- TemplateHead/TemplateFooter for HTML injection
- TemplateData for template data enrichment
- BasePlugin provides no-op defaults

Registry (plugin/registry.go):
- Register compiled-in plugins
- Init seeds plugin settings, calls OnInit
- StartScheduledJobs launches background goroutines
- InjectTemplateData enriches template data before render
- GetAllSettings for admin UI
- Gin middleware stores registry on context

Dynamic loading (plugin/loader.go):
- Yaegi Go interpreter loads .go files from plugins/dynamic/
- Dynamic plugins implement the same Plugin interface
- No recompilation needed for dynamic plugins

Integration:
- Blog.Render() helper wraps c.HTML with plugin data injection
- All 32 c.HTML calls in blog.go replaced with b.Render
- Plugin middleware added to router in goblog.go
- Template injection points in all theme headers/footers
- Plugin settings section on admin settings page

Proof of concept: Google Analytics plugin (plugins/analytics/)
- Configurable measurement ID and enable/disable toggle
- Injects GA script into <head> when enabled

Closes #480

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 17, 2026 02:54
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a plugin system for GoBlog that supports both compiled-in plugins and dynamically loaded plugins (via Yaegi), and integrates plugin-provided settings + template injections into the existing admin UI and rendering pipeline.

Changes:

  • Adds a plugin package (plugin interface, registry, Yaegi loader, middleware) and a proof-of-concept Google Analytics plugin.
  • Wraps HTML rendering via Blog.Render() to inject plugin_head_html, plugin_footer_html, and plugins template data across pages.
  • Extends admin settings UI/JS to display and update plugin settings.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
www/js/admin-script.js Adds updatePluginSettings() AJAX submission for plugin settings.
themes/minimal/templates/header.html Adds plugin_head_html injection point.
themes/minimal/templates/footer.html Adds plugin_footer_html injection point.
themes/forest/templates/header.html Adds plugin_head_html injection point.
themes/forest/templates/footer.html Adds plugin_footer_html injection point.
themes/default/templates/header.html Adds plugin_head_html injection point.
themes/default/templates/footer.html Adds plugin_footer_html injection point.
themes/default/templates/admin_settings.html Adds plugin settings section to admin settings page.
plugins/analytics/analytics.go Adds compiled-in Google Analytics plugin.
plugin/plugin.go Defines plugin interface, hook context, settings/jobs types, and BasePlugin defaults.
plugin/registry.go Implements plugin registry, template injection, settings grouping, scheduled jobs, and Gin middleware.
plugin/loader.go Implements dynamic plugin loading via Yaegi.
plugin/symbols.go Exports plugin types to Yaegi interpreter.
plugin/registry_test.go Adds tests for registry basics, seeding, template injection, and settings grouping.
goblog.go Wires plugin registry, dynamic loading, init, job start, and middleware into server startup.
blog/blog.go Adds Blog.Render() helper and migrates handlers to use it.
admin/admin.go Injects plugin settings groups into admin settings page template data.
go.mod / go.sum Adds Yaegi dependency.
Comments suppressed due to low confidence (1)

blog/blog.go:1026

  • b.Render is being called with a non-template string ("Error loading .env file: ...") as templateName. Gin will treat this as a template filename and fail to render, breaking the login page when both .env and local.env are missing/unreadable. Use an actual template (e.g. error.html) and pass the error message via the data map (e.g. description/error).
			b.Render(c, http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{
				"logged_in":  b.auth.IsLoggedIn(c),
				"is_admin":   b.auth.IsAdmin(c),
				"version":    b.Version,
				"title":      "Login Configuration Error",
				"recent":     b.GetLatest(),
				"admin_page": false,
				"settings":   b.GetSettings(),
				"nav_pages":  b.GetNavPages(),
			})
			return

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +318 to +321
}

router.Use(CORS())
router.Use(gplugin.Middleware(registry))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +91 to +105
go func(p Plugin, job ScheduledJob) {
ticker := time.NewTicker(job.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
settings := r.getPluginSettings(p.Name())
if err := job.Run(r.db, settings); err != nil {
log.Printf("Plugin %s job %s error: %v", p.Name(), job.Name, err)
}
case <-r.stopCh:
return
}
}
}(p, job)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

plugin/loader.go Outdated
Comment on lines +14 to +17
// using the Yaegi Go interpreter. Each file must define a function:
//
// func NewPlugin() plugin.Plugin
//
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

goblog.go Outdated
// Initialize plugin system
registry := gplugin.NewRegistry(db)
registry.Register(analytics.New())
gplugin.LoadDynamicPlugins(registry, "plugins/dynamic")
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +34 to +45
<form id="plugin-settings-form">
{{ range .plugin_settings }}
<h4>{{ .DisplayName }}</h4>
{{ $pluginName := .PluginName }}
{{ $values := .CurrentValues }}
{{ range .Settings }}
<label for="{{ $pluginName }}.{{ .Key }}" class="form-label">{{ .Label }}</label>
{{ if .Description }}<small class="text-muted d-block mb-1">{{ .Description }}</small>{{ end }}
{{ if eq .Type "textarea" }}
<textarea id="{{ $pluginName }}.{{ .Key }}" name="{{ $pluginName }}.{{ .Key }}" class="form-control mb-2" rows="3">{{ index $values .Key }}</textarea>
{{ else }}
<input type="text" id="{{ $pluginName }}.{{ .Key }}" name="{{ $pluginName }}.{{ .Key }}" value="{{ index $values .Key }}" class="form-control mb-2">
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +7 to +13
var Symbols = map[string]map[string]reflect.Value{
"goblog/plugin/plugin": {
"BasePlugin": reflect.ValueOf((*BasePlugin)(nil)),
"HookContext": reflect.ValueOf((*HookContext)(nil)),
"SettingDefinition": reflect.ValueOf((*SettingDefinition)(nil)),
"ScheduledJob": reflect.ValueOf((*ScheduledJob)(nil)),
},
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +68 to +71
p, ok := v.Interface().(Plugin)
if !ok {
return nil, err
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +232 to +237
$("#plugin-settings-form :input").each(function() {
var key = this.name;
var type = this.tagName === "TEXTAREA" ? "textarea" : "text";
var value = this.value;
if (this.type === "submit" || !key) return;
settings.push({"key": key, "value": value, "type": type});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +43 to +74
func loadPlugin(path string) (Plugin, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}

i := interp.New(interp.Options{})
if err := i.Use(stdlib.Symbols); err != nil {
return nil, err
}
// Export the plugin package symbols so dynamic plugins can use them
if err := i.Use(Symbols); err != nil {
return nil, err
}

_, err = i.Eval(string(src))
if err != nil {
return nil, err
}

v, err := i.Eval("NewPlugin()")
if err != nil {
return nil, err
}

p, ok := v.Interface().(Plugin)
if !ok {
return nil, err
}

return p, nil
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +45 to +47
db, _ := gorm.Open(sqlite.Open(":memory:"))
db.AutoMigrate(&blog.Setting{})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

compscidr and others added 10 commits March 16, 2026 20:05
Theme switching:
- Reload page after theme change so new templates take effect
  immediately instead of showing stale "Settings updated" message

Admin settings layout:
- Separate site settings into a "General" card
- Each plugin gets its own card with header showing display name
- Plugin save buttons are per-plugin, not one global button
- updatePluginSettings now scopes to the closest form

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Plugin cards are collapsed by default, expanded only when enabled
- Enabled setting rendered as a Bootstrap switch checkbox in the card
  header instead of a text input
- Toggling the checkbox immediately saves the enabled state and
  expands/collapses the settings card
- The "enabled" field is excluded from the settings form body since
  it lives in the card header
- Save button includes the enabled state along with other settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Card header is clickable to expand/collapse regardless of enabled state
- Cards start collapsed by default so the page is clean
- Enable/disable toggle only saves the setting, doesn't control collapse
- stopPropagation on the switch prevents clicking it from also
  toggling the collapse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin settings were stored in the main settings table with namespaced
keys (analytics.enabled, analytics.tracking_id), which caused them to
appear in the global settings form.

- Add PluginSetting model with composite PK (plugin_name, key)
- Auto-migrate plugin_settings table in Registry.Init()
- Add UpdateSetting method on Registry
- Add PATCH /api/v1/plugin-settings endpoint for saving plugin settings
- Update JS to use the new endpoint for both toggle and form save
- Remove blog.Setting dependency from plugin package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove any dot-namespaced keys (e.g. analytics.enabled) from the
settings table that were seeded before plugin settings moved to
their own plugin_settings table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move social URL settings out of the core settings table and hardcoded
footer templates into a socialicons plugin.

Plugin (plugins/socialicons/):
- Defines settings for 10 social platforms (GitHub, LinkedIn, X, etc.)
- TemplateFooter renders icon links with Font Awesome
- TemplateData provides structured links list for themes that want it
- Enabled by default

Migration:
- Moves existing social URL values from settings to plugin_settings
- Removes the social URL keys from the main settings table
- Sets socialicons.enabled to true for existing installs

Footer templates:
- Removed hardcoded social icon blocks from all three themes
- Social icons now rendered via {{ .plugin_footer_html }}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the scholar integration out of the Blog struct and into a
standalone plugin. The plugin defines a "research" dynamic page type
that renders when enabled, and disappears from the site when disabled.

Plugin interface additions:
- Pages() — plugins can register dynamic page types
- RenderPage() — plugins handle rendering their own pages
- Registry.GetPagePlugin/RenderPluginPage/GetNavItems for page routing
- Registry.IsPluginEnabled for checking enabled state

Scholar plugin (plugins/scholar/):
- Owns the scholar.Scholar instance internally
- Configurable: scholar_id, article_limit, cache file paths
- Scheduled job: daily cache refresh in background
- Renders page_research.html with articles data

Blog changes:
- Removed scholar field from Blog struct
- Removed Blog.New scholar parameter
- Removed Research handler and sortArticlesByDateDesc
- DynamicPage default case now delegates to plugin registry
- Moved sort test to plugin package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- OnInit ensures a research page exists in the pages table with
  default title, slug, nav settings. Users can customize these
  via the admin page editor.
- Migrates ScholarID from legacy page field to plugin settings
  for backward compatibility with existing installs.
- When the scholar plugin is disabled, visiting the research page
  shows a "Page Not Available" 404 instead of an empty content page.
- Added HasPageType to registry to distinguish "plugin disabled" from
  "no plugin for this page type".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove PageTypeResearch constant (now plugin-defined)
- Mark ScholarID on Page as deprecated (kept for migration compat)
- Add PageFilter to Blog struct, applied in GetNavPages to filter
  out pages owned by disabled plugins
- Add IsPageTypeEnabled to registry for checking if the plugin that
  owns a page type is enabled
- Wire up the filter in goblog.go after registry init
- Update seed defaults and tests to use string literal "research"

When the scholar plugin is disabled:
- Research page disappears from navigation
- Visiting /research shows "Page Not Available" 404
When re-enabled, everything comes back automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Admin pages list (/admin/pages):
- Rows for pages with disabled plugins highlighted in yellow
- "plugin disabled" badge shown next to the page type
- Enabled column shows "Plugin disabled" instead of Yes/No

Admin page editor (/admin/pages/:id):
- Warning banner at top when the page's plugin is disabled, with
  link to Settings to enable it
- Removed scholar-specific Google Scholar ID field (now in plugin
  settings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@compscidr compscidr requested a review from Copilot March 17, 2026 04:02
@compscidr compscidr changed the title Implement plugin system Implement plugin system with analytics, social icons, and scholar plugins Mar 17, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a first-class plugin system for goblog (compiled-in and dynamically loaded via Yaegi), integrates plugin-provided data/HTML into template rendering, and adds admin UI + migrations to support plugin settings and plugin-owned pages.

Changes:

  • Add plugin framework (registry, settings table, middleware, Yaegi dynamic loader) plus initial plugins (Analytics, Social Icons, Scholar).
  • Route all template rendering through Blog.Render() so plugins can inject template data and head/footer HTML.
  • Update admin UI + migrations: plugin settings management endpoint/UI, social URL settings migrated into plugin_settings, and admin page warnings when plugin-owned page types are disabled.

Reviewed changes

Copilot reviewed 34 out of 35 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
www/js/admin-script.js Adds plugin settings AJAX updates and a theme-change-triggered reload after saving settings.
tools/migrate.go Migrates social URL settings into plugin settings and adds plugin-settings cleanup logic.
themes/minimal/templates/header.html Adds plugin_head_html injection point.
themes/minimal/templates/footer.html Replaces hardcoded social icons with plugin_footer_html.
themes/minimal/templates/admin_settings.html Adds plugin settings UI groups with enable toggle and collapsible sections.
themes/minimal/templates/admin_pages.html Highlights pages whose page type belongs to a disabled plugin.
themes/minimal/templates/admin_edit_page.html Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields.
themes/forest/templates/header.html Adds plugin_head_html injection point.
themes/forest/templates/footer.html Replaces hardcoded social icons with plugin_footer_html.
themes/forest/templates/admin_settings.html Adds plugin settings UI groups with enable toggle and collapsible sections.
themes/forest/templates/admin_pages.html Highlights pages whose page type belongs to a disabled plugin.
themes/forest/templates/admin_edit_page.html Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields.
themes/default/templates/header.html Adds plugin_head_html injection point.
themes/default/templates/footer.html Adds plugin_footer_html injection (currently duplicated).
themes/default/templates/admin_settings.html Adds plugin settings UI groups with enable toggle and collapsible sections.
themes/default/templates/admin_pages.html Highlights pages whose page type belongs to a disabled plugin.
themes/default/templates/admin_edit_page.html Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields.
plugins/socialicons/socialicons.go New Social Icons plugin that injects footer HTML + structured template data.
plugins/scholar/scholar_test.go Moves/renames test package/imports to match plugin location.
plugins/scholar/scholar.go New Scholar plugin implementing a plugin-owned “research” page + scheduled cache refresh job.
plugins/analytics/analytics.go New Google Analytics plugin injecting tracking script into page head when enabled.
plugin/symbols.go Exposes plugin types to Yaegi (dynamic plugin support).
plugin/setting.go Adds PluginSetting model for plugin settings persistence.
plugin/registry_test.go Adds tests for registry basics, setting seeding, and template injection.
plugin/registry.go Adds plugin registry, settings seeding, template injection, scheduled jobs, page ownership, and middleware.
plugin/plugin.go Defines plugin interface, hook context, scheduled jobs, page definitions, and BasePlugin.
plugin/loader.go Implements Yaegi-based dynamic plugin loading from .go files.
goblog.go Wires registry into app startup, registers compiled-in plugins, adds middleware + plugin settings API route, and filters nav pages via plugins.
go.mod Adds Yaegi dependency.
go.sum Adds Yaegi checksums.
blog/page.go Updates PageType comments and deprecates ScholarID field usage.
blog/blog_test.go Updates tests for new blog.New(...) signature and research page type usage.
blog/blog.go Removes built-in scholar handling, introduces Blog.Render() wrapper, and routes render calls through it; adds plugin page rendering fallback.
admin/admin_test.go Updates tests for new blog.New(...) signature.
admin/admin.go Adds plugin settings retrieval for templates, disabled-plugin-page warnings, and new plugin settings PATCH handler.
Comments suppressed due to low confidence (1)

blog/blog.go:996

  • Login() passes an error message string as the template name to Render, which will make Gin try to render a template named like "Error loading .env file: ...". This should render a real template (e.g. error.html) and pass the error message via the template data.
			b.Render(c, http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{
				"logged_in":  b.auth.IsLoggedIn(c),
				"is_admin":   b.auth.IsAdmin(c),
				"version":    b.Version,
				"title":      "Login Configuration Error",

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +90 to +94
if url == "" {
continue
}
html += `<a href="` + url + `" target="_blank" rel="noopener noreferrer" title="` + s.label + `" style="margin: 0 6px; color: inherit;"><i class="` + s.icon + ` fa-1x"></i></a>`
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +49 to +53
result := db.Where("page_type = ?", "research").First(&page)
if result.Error != nil {
// No research page exists — create the default
page = blog.Page{
Title: "Research",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

func (r *Registry) Plugins() []Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
return r.plugins
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

goblog.go Outdated
Comment on lines +312 to +316
registry := gplugin.NewRegistry(db)
registry.Register(analytics.New())
registry.Register(socialicons.New())
registry.Register(scholarplugin.New())
gplugin.LoadDynamicPlugins(registry, "plugins/dynamic")
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

tools/migrate.go Outdated
Comment on lines +422 to +424
func cleanupPluginSettingsFromMainTable(db *gorm.DB) {
result := db.Exec("DELETE FROM settings WHERE key LIKE '%.%'")
if result.Error != nil {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +68 to +71
p, ok := v.Interface().(Plugin)
if !ok {
return nil, err
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

plugin/plugin.go Outdated
)

// SettingDefinition describes a single setting that a plugin requires.
// Settings are stored in the blog's Setting table namespaced as "pluginname.key".
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines +49 to +53
enabled := ctx.Settings["enabled"]
if trackingID == "" || enabled != "true" {
return ""
}
return `<script async src="https://www.googletagmanager.com/gtag/js?id=` + trackingID + `"></script>
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Comment on lines 1 to 4
<div id="footer" class="text-center">
<div class="footer">
{{ if .settings.github_url.Value }}
<a href="{{ .settings.github_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on Github"><i class="fab fa-github fa-1x"></i></a>
{{ end }}
{{ if .settings.linkedin_url.Value }}
<a href="{{ .settings.linkedin_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on LinkedIn"><i class="fab fa-linkedin fa-1x"></i></a>
{{ end }}
{{ if .settings.x_url.Value }}
<a href="{{ .settings.x_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on X"><i class="fab fa-x fa-1x"></i></a>
{{ end }}
{{ if .settings.keybase_url.Value }}
<a href="{{ .settings.keybase_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on Keybase"><i class="fab fa-keybase fa-1x"></i></a>
{{ end }}
{{ if .settings.instagram_url.Value }}
<a href="https://www.instagram.com/compscidr/" target="_blank" title="Jason Ernst on Instagram"><i class="fab fa-instagram fa-1x"></i></a>
{{ end }}
{{ if .settings.facebook_url.Value }}
<a href="{{ .settings.facebook_url.Value }}" target="_blank" title="Jason Ernst on Facebook"><i class="fab fa-facebook fa-1x"></i></a>
{{ end }}
{{ if .settings.strava_url.Value }}
<a href="{{ .settings.strava_url.Value }}" target="_blank" title="Jason Ernst on Strava"><i class="fab fa-strava fa-1x"></i></a>
{{ end }}
{{ if .settings.spotify_url.Value }}
<a href="{{ .settings.spotify_url.Value }}" target="_blank" title="Jason Ernst on Spotify"><i class="fab fa-spotify fa-1x"></i></a>
{{ end }}
{{ if .settings.xbox_url.Value }}
<a href="{{ .settings.xbox_url.Value }}" target="_blank" title="Jason Ernst on Xbox"><i class="fab fa-xbox fa-1x"></i></a>
{{ end }}
{{ if .settings.steam_url.Value }}
<a href="{{ .settings.steam_url.Value }}" target="_blank" title="Jason Ernst on Steam"><i class="fab fa-steam fa-1x"></i></a>
{{ end }}
</div> <!-- /footer -->
{{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }}
<div class="footer"></div>
<div class="spacer version">Powered by <a href="https://github.com/compscidr/goblog" target="goblog {{ .version }}">goblog {{ .version }}</a></div>
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

{{ with index .settings "custom_footer_code" }}{{ if .Value }}
{{ .Value | rawHTML }}
{{ end }}{{ end }}
{{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eff69a7.

Security:
- Analytics: validate tracking ID with regex (alphanumeric + hyphens only)
- Social icons: escape URLs and labels with html.EscapeString
- Dynamic plugin loading now opt-in via ENABLE_DYNAMIC_PLUGINS env var

Concurrency:
- Scholar: use sync.Once for ensureScholar instead of bare nil check
- Scheduled jobs: copy r.db under RLock before using in goroutine
- Registry.Plugins() returns a copy of the internal slice

Robustness:
- Nil DB guards in getPluginSettings and InjectTemplateData
- Scholar OnInit: check for gorm.ErrRecordNotFound specifically
- Scholar OnInit: check db.Create error
- Dynamic loader: return descriptive error on type assertion failure
- Dynamic loader: document package main requirement
- Yaegi symbols: fix import path key, export Plugin interface
- Plugin registry stored on goblog struct, UpdateDb/Init/StartScheduledJobs
  called when wizard establishes DB connection

Admin UI:
- Fix duplicate plugin_footer_html injection in default footer
- Theme reload only fires when theme value actually changed
- Checkbox reverts on failed plugin toggle save
- SettingDefinition comment updated to match plugin_settings table

Migration:
- Narrow cleanup to known plugin prefixes instead of all dot-namespaced keys
- Registry test errors properly checked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@compscidr compscidr merged commit 5021910 into main Mar 17, 2026
1 check passed
@compscidr compscidr deleted the feature/plugin-system branch March 17, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

implement a plugin system

2 participants